容量规划与压测基线:从"省了多少"到"够不够用" 原创
本文是 SRE 实战系列第 21 篇。第 16 篇算的是"省了多少",这篇算"够不够用"——两个问题的答案经常互相打架,而多数团队只在出了事之后才发现自己一直在用错误的那个。
全文约 1.3 万字。从一次看起来毫无道理的故障开头:集群 CPU 只用了 40%,扩容也做了,但 20:00 一过,下单接口还是开始超时。然后回答五个问题:容量到底该用哪个单位衡量、压测基线怎么建才不会三个月就作废、单服务压测和全链路压测差在哪、水位线怎么和业务日历绑起来、以及"这台机器还能扛多少"怎么换算成钱。
这一篇的立场:容量不是一个数字,是一条"还能撑多久"的曲线。任何只报平均值的容量结论,都等于没报。
第 19 篇管"变更别引入故障",第 20 篇管"故障来了还活着",这一篇管第三件事——在故障发生之前,就知道自己还有多少余量。前两篇里的灰度和演练都需要分母,这个分母就是本篇要算的东西。
一、CPU 40%,为什么还是挂了
一个电商平台在 8 月做过一次扩容,过程很顺利:订单服务从 24 个副本扩到 40 个,节点加了 8 台,账单涨了 31%。扩完之后的一周,集群 CPU 平均利用率 40%,内存 55%,看起来余量充足。
然后在一个工作日的 20:00 的时候触发问题:
20:02,下单接口 p99 从 180ms 涨到 2.1s,错误率从 0.01% 涨到 4%。告警触发,值班同学第一反应是"流量涨了",于是执行 Runbook 里的扩容:副本从 40 加到 60。
20:06,副本起来了,p99 没有下降。
20:14,继续加,60 加到 80。p99 还是 2s 上下。
20:30,有人去查了数据库,发现订单库的连接数打到了
max_connections上限,新请求在连接池里排队,超时时间就是那个 2s。扩容把 Web 层的并发提高了,等于把更多的请求堆到同一个数据库连接池前面。 加副本让情况稍微更糟了一点。20:47,把订单服务的连接池上限从每副本 30 降到 15,总数从 2400 降到 1200,同时打开订单查询的缓存,p99 回到 300ms。
事后复盘,一共三个数字很刺眼:
- 扩容决策依据是 CPU 平均利用率 40%,而 20:00 前后那一刻,订单服务所在节点的 CPU 峰值是 78%——平均值把两个小时的尖峰抹平了
- 瓶颈在连接数,而连接数从来没有进过容量模型。这个指标在监控里存在,但它不在"容量"这个话题下
- 从 40 副本扩到 80 副本,单位容量没有变:每个副本还是只能处理那么多并发,因为天花板在它后面的共享资源上
这三个数字指向同一件事:这个团队有"资源使用率",但没有"容量"。
这两者不一样。资源使用率回答"我用掉了我有的多少",容量回答"我还能够用多久"。前者是除法的结果,后者是乘法的外推。一个只看使用率的团队,永远在追赶;一个知道容量的团队,提前两周就知道要做什么。
二、容量的单位:不是核,不是 QPS,是"每秒多少资源秒"
要把容量讲清楚,必须先统一单位。否则每次讨论都在各说各的:平台团队说"还有 30% CPU",业务团队说"还能再上一倍流量",数据库团队说"连接数剩 200",这三个数之间没有换算关系。
2.1 从 x = λ × W 说起
任何一个排队系统,最基础的关系是利特尔法则(Little's Law):
并发数 L = 到达率 λ × 停留时间 W一个实例在单位时间内能承接多少请求,取决于它处理一个请求需要占用多少资源、以及它有多少资源。把它换算成统一的记账单位:
一个实例的容量 C = 可用的资源总量 / 单个请求消耗的资源
例如 CPU:C (req/s) = 分配的核数 × 单核每秒能处理的请求数
再如 DB:C (req/s) = 连接数上限 / 单请求平均持有连接时间这个式子看起来是同义反复,但它带来一个很重要的纪律:每一条容量结论都必须写成"某个具体资源的每秒容量",并且写明是哪个资源。
| 层 | 资源单位 | 容量表达式 | 常见误用 |
|---|---|---|---|
| 接入 / 网关 | 连接数、TLS 握手次数 | 每秒新建连接数 | 只报并发连接数,不报新建速率 |
| 应用(无状态) | CPU 核、Goroutine/线程 | 核数 × 单核吞吐 | 用 CPU 使用率代替吞吐 |
| 应用(有状态/池化) | 连接、文件描述符、临时端口 | 连接上限 / 持有时间 | 忘掉 TIME_WAIT 和端口耗尽 |
| 缓存 | 带宽、命令数、内存 | 单分片 QPS × 分片数 | 把集群总 QPS 当单 key 热点能力 |
| 数据库 | 连接、IOPS、锁 | 连接 / 持有时间、IOPS / 每事务 IO | 只看 CPU,不看连接和磁盘 |
| 消息队列 | 分区数、消费速率 | 分区数 × 单分区吞吐 | 忘了生产者比消费者快时的堆积 |
| 第三方依赖 | 配额(QPS、日调用量)、超时 | 配额 / 峰值系数 | 从来不是"无限" |
表格最后一行的"第三方"值得单独说一句:很多容量事故的瓶颈不在自己家里。支付渠道限流、短信配额、云厂商 API 的限频、某个第三方 SDK 的连接数——这些资源在你自己监控里通常看不到,只在对方的返回码里出现。
2.2 一个可以直接用的容量单元:资源秒
讨论容量时,最省事的做法是把所有东西换算成**"一次请求消耗多少资源秒"**,然后把容量也写成资源秒。
举例,某商品详情接口,压测测得:
- 单次请求消耗 CPU 8ms(即 0.008 CPU 核秒)
- 单次请求消耗数据库连接 12ms(即 0.012 连接秒)
- 单次请求消耗 Redis 带宽 4KB
那么集群有 100 核可用、数据库允许 2000 个连接、Redis 带宽 100MB/s,三个上限分别是:
CPU 上限 : 100 / 0.008 = 12500 req/s
DB 连接上限 : 2000 / 0.012 = 166000 req/s
Redis 带宽上限: 100MB / 4KB = 25000 req/s于是这个接口的容量是 12500 req/s,瓶颈是 CPU,其余两个还有 2 倍以上余量。
这套算法一点都不高深,但它的输出有三个别的方法给不出的东西:
- 瓶颈是谁,明确到资源名,不用等出事了再猜
- 离下一个瓶颈有多远,也就是扩 CPU 之后能扩到多少(本例 166000 是理论天花板,实际要留冗余)
- 某个层级的改动会不会白花钱:如果 CPU 换成更便宜的机型而单次消耗从 8ms 涨到 14ms,容量掉 43%,任何"降本"都白做
第 16 篇讨论过降本和稳定性的冲突,这里给出了冲突的量化方式:先把单位请求的资源消耗量测出来,再讨论要不要花钱。 没有这个数,降本和容量就是两拨人各自喊话。
2.3 排队论的唯一一条结论
容量为什么不能"刚好用完",必须留余量?因为响应时间并不是线性增长,而是双曲线。
对 M/M/1 这类最简单的模型,平均排队时间:
W = (1 / μ) × 1 / (1 - ρ)
其中 μ 是服务率,ρ = λ / μ 是利用率把这个式子在不同 ρ 下算出来,取单次服务时间 1/μ = 100ms:
| 利用率 ρ | 平均响应时间 | 相对空载 |
|---|---|---|
| 50% | 200ms | 2.0× |
| 70% | 333ms | 3.3× |
| 80% | 500ms | 5.0× |
| 90% | 1000ms | 10.0× |
| 95% | 2000ms | 20.0× |
| 99% | 10000ms | 100× |
(真实系统有连接池、有队列上限、有多个副本,曲线形状会平缓一些,但拐点的位置不会变:都在 70% 到 90% 之间。)
这张表就是"为什么必须留余量"的全部理由,也是"80% 使用率"这类经验值的来源。它同时解释了上一节那个案例里的一个反常现象:扩容之后情况更糟——因为副本解决了"算力"问题,却把并发推向了下一个已经接近 ρ=0.95 的资源。
由此可以给出本篇的第一条经验法则:
每个资源都要有一条明确的"软水位"和"硬水位"。软水位以下自由,软硬之间必须有人决策,硬水位以上视同故障。
至于软硬水位定多少,第三节讲。
三、把容量写成一条水位线
3.1 水位线的三层结构
容量不是一个数,是三层:
| 层 | 回答的问题 | 数据来源 | 更新频率 |
|---|---|---|---|
| 单机承载量 | 1 个副本能扛多少 | 单机压测 | 每季度或大版本 |
| 集群承载量 | 整套能扛多少,瓶颈在哪一层 | 全链路压测 + 资源账 | 每季度 |
| 业务承载量 | 能换算成多少单 / 多少 DAU / 多少 GMV | 业务指标映射 | 大促前 |
三层要一起有,缺一层就会在某个环节断裂。只有单机承载量的团队,会在真实峰值里发现瓶颈在下游;只有业务承载量的团队,会在业务口径变化时(比如新增一个下单入口)完全失去判断力。
3.2 单机承载量怎么测
单机压测最容易做,也最容易被做成废数据。四个必须固定下来的变量:
变量一:负载模型。 到底是"恒定 QPS 打到底"还是"按真实流量的比例分布"。前者能测上限,后者能测真实表现,两者都要,但要分开做,不要混在一次实验里。
变量二:数据规模。 1 万条数据的表和 1000 万条数据的表,同一个 SQL 的耗时差一个数量级。用小数据集压出来的容量是假的。做法是:从生产导一份脱敏样本,或者按生产的数据分布生成。
变量三:缓存状态。 冷缓存和热缓存下的 QPS 能差 5 倍以上。压测必须声明是"冷启动"还是"稳态",否则数据没法比较。
变量四:依赖的行为。 压测环境的下游如果是 mock,那测出来的只是"本服务的算力",不是"真实链路的容量"。下游必须有真实的延迟和真实的限流,否则压测结果会系统性偏乐观。
还有一个变量经常被漏掉:连接池的热身。 连接池在压测开始的前 30 秒是在建连的,请求排队,p99 会虚高,看起来像"容量只有标称的 60%"。等连接池稳定后同一个 QPS 就正常了——所以压测记录里必须有一句"结果取自第 4 分钟之后",否则数据没法比较。
一个可以直接用的单机压测记录表(每季度填一次,放在 Wiki 里):
服务名 order-api
镜像版本 order-api:2.18.3 (sha 7f3a1c9)
副本资源 cpu 2 核 / mem 4Gi
数据集 1,200 万订单行,199 万用户
负载模型 恒定 QPS 阶梯 +30/分钟
冷/热 热缓存(预热 10 分钟)
下游 mock(延迟已注入 P50 8ms / P99 45ms)
----------------------------------------------------
QPS p50 p99 错误率 CPU 内存 备注
500 42ms 120ms 0% 18% 31%
1000 45ms 140ms 0% 35% 36%
1500 48ms 165ms 0% 52% 39%
2000 52ms 210ms 0% 69% 43%
2500 58ms 380ms 0.01% 85% 47%
3000 71ms 900ms 0.4% 97% 52% ← 拐点
3500 120ms 2600ms 3.1% 100% 55%
----------------------------------------------------
结论:单副本安全承载 2000 QPS(CPU 69%),极限 2500 QPS。
拐点由 CPU 决定,扩 CPU 到 4 核需重测(预期 ~3800,但需验证 DB 连接是否成为新瓶颈)。最后那行"结论"是整张表的全部价值。没有结论的压测报告等于没有压测。 而且结论必须带条件("在 2 核 / 热缓存 / mock 延迟 P99 45ms 下"),因为换了任何一个条件它都可能作废。
3.3 软水位与硬水位怎么定
按第 2.3 节的曲线,通用经验值是这样的:
| 资源类型 | 软水位 | 硬水位 | 理由 |
|---|---|---|---|
| CPU(应用) | 60% | 85% | 80% 以上 p99 开始明显恶化 |
| 内存(有 GC) | 70% | 85% | 留出 GC 峰值与 heap 抖动空间 |
| 内存(无 GC) | 80% | 92% | 但要防 OOMKill,注意 limit 与 request 的关系 |
| 数据库连接数 | 60% | 80% | 超过 80% 后排队时间非线性上涨,且没有兜底 |
| 数据库 CPU | 50% | 75% | 数据库是共享资源,比应用更保守 |
| 磁盘使用率 | 70% | 85% | 留出日志与临时文件空间 |
| 队列积压(时间) | 5 分钟 | 30 分钟 | 积压时间比条数更能反映用户影响 |
| 依赖配额 | 60% | 80% | 第三方限频通常没有商量的余地 |
两条补充规则比这张表本身更重要:
- 水位要按峰值算,不按均值算。 日常监控曲线上的"平均 40%"没有决策价值,有价值的是"过去 30 天同时段 P95"。前文那个案例里,78% 的峰值就藏在 40% 的均值里。
- 水位线对每一条资源都要有,包括那些"不归你管"的:数据库、消息队列、第三方配额。它们恰恰是最常在半夜出问题的。
3.4 一条可以直接跑的水位计算
把每个资源的软硬水位做成一条规则清单,用一份 YAML 维护,然后用脚本每天算一次并输出"距离硬水位还有几天":
# capacity/watermarks.yaml
resources:
- name: order-api.cpu
query: |
max(
1 - avg by (pod) (rate(container_cpu_usage_seconds_total{namespace="prod",container="order-api"}[5m]))
/ on(pod) group_left() kube_pod_container_resource_requests{namespace="prod",container="order-api",resource="cpu"}
)
soft: 0.60
hard: 0.85
window: 30d # 用近 30 天同时段 P95,不用瞬时值
- name: order-api.db_conn
query: |
max by (instance) (mysql_global_status_threads_connected)
/ on(instance) group_left() mysql_global_variables_max_connections
soft: 0.60
hard: 0.80
window: 7d
- name: order-api.queue_lag_seconds
query: |
max(kafka_consumergroup_lag_sum{group="order-consumer"})
/ sum(rate(kafka_consumergroup_current_offset{group="order-consumer"}[5m]))
soft: 300
hard: 1800
window: 1d#!/usr/bin/env bash
# capacity/watermark-check.sh —— 每天 09:00 跑,输出到容量看板
set -euo pipefail
PROM="${PROM_URL}"
OUT="capacity/report/$(date +%F).tsv"
printf "resource\tp95\tsoft\thard\t状态\t按当前周增速可撑\n" > "$OUT"
yq -o=json '.resources' capacity/watermarks.yaml | jq -c '.[]' | while read -r r; do
name=$(echo "$r" | jq -r '.name')
query=$(echo "$r" | jq -r '.query')
soft=$(echo "$r" | jq -r '.soft')
hard=$(echo "$r" | jq -r '.hard')
win=$(echo "$r" | jq -r '.window')
# 取窗口内的 P95,而不是瞬时值
p95=$(curl -sG "$PROM/api/v1/query" \
--data-urlencode "query=quantile_over_time(0.95, ($query)[$win:5m])" \
| jq -r '.data.result[0].value[1] // "0"')
# 近 4 周的周增速,用于外推还能撑几周
growth=$(curl -sG "$PROM/api/v1/query" \
--data-urlencode "query=(quantile_over_time(0.95,($query)[1d:5m]) - quantile_over_time(0.95,($query)[28d:5m] offset 1d)) / 4" \
| jq -r '.data.result[0].value[1] // "0"')
weeks=$(awk -v h="$hard" -v p="$p95" -v g="$growth" \
'BEGIN{ if (g<=0.0001) print "∞"; else printf "%.1f", (h-p)/g }')
status=$(awk -v h="$hard" -v s="$soft" -v p="$p95" \
'BEGIN{ if (p>=h) print "P0-已破硬水位"; else if (p>=s) print "P1-已过软水位"; else print "OK" }')
printf "%s\t%.3f\t%s\t%s\t%s\t%s 周\n" "$name" "$p95" "$soft" "$hard" "$status" "$weeks" >> "$OUT"
done
cat "$OUT"这个脚本的关键不在脚本本身,在最后一列:"按当前增速还能撑几周"。它把一个静态的百分比变成了一个有方向的预测量。OK / 已过软水位 只能告诉你现在,3.2 周 才能告诉你什么时候该动手。
外推的假设必须写清楚,因为它迟早会被打破:假设是"增速保持近四周的线性趋势、水位线不变"。大促、发版节奏变化、某个渠道放量,都会让它失效——所以第三列和第四列都要同时看,不能只看最后一列。
四、压测基线:建一次,还是建一套能活下来的东西
很多团队做过压测,但没有基线。区别在哪?
压测是一次活动:大促前两周,找几个人,用 JMeter 或 k6 打一遍,出一份 PPT,写着"系统可支撑 5000 QPS"。然后这份 PPT 再也没人打开。
基线是一份资产:脚本在仓库里、数据集可重建、环境可复现、每次结论都带条件,并且能对比——这次 2.18 版本比 2.17 版本的单机容量低了 12%,这个下降是可定位、可追责、可回滚的。
4.1 一个可复现的压测仓库
基线能不能活下去,90% 取决于它是否可复现。不可复现的压测结论,三个月后连自己都不敢引用。
loadtest/
├── scenarios/
│ ├── order-create.js # k6 脚本,版本受控
│ └── product-detail.js
├── datasets/
│ ├── generate.sh # 按生产分布生成,脱敏
│ └── README.md # 数据量与分布说明
├── env/
│ ├── staging.yaml # 环境规格、副本数、下游 mock 延迟
│ └── baseline-2026Q3.tsv # 已归档的基线结果
├── thresholds.yaml # 什么算通过
└── run.sh # 一键跑完并归档三个"必须":
- 脚本必须声明环境规格。同一份脚本,在 2 核和 4 核上跑出的数字没有可比性。
env/staging.yaml里要写死资源规格、副本数、下游延迟注入值 - 数据集必须可重建。
generate.sh要能生成同等规模同等分布的数据,而不是"上周从生产导的那份,现在找不到了" - 结果必须归档成结构化格式。TSV / CSV,不要截图。截图无法做趋势对比
4.2 压测脚本:负载模型比工具重要
// loadtest/scenarios/order-create.js
import http from 'k6/http';
import { check } from 'k6';
import { Trend } from 'k6/metrics';
const bizLatency = new Trend('biz_latency', true);
export const options = {
scenarios: {
// 场景一:阶梯爬坡,找拐点
ramp: {
executor: 'ramping-arrival-rate',
startRate: 200,
timeUnit: '1s',
preAllocatedVUs: 500,
maxVUs: 8000,
stages: [
{ target: 1000, duration: '3m' }, // 每级 3 分钟,等系统热起来
{ target: 2000, duration: '3m' },
{ target: 3000, duration: '3m' },
{ target: 4000, duration: '3m' },
{ target: 0, duration: '1m' },
],
tags: { scenario: 'capacity' },
},
},
thresholds: {
// 拐点判定:这三个条件任一被打破,就认为到了拐点
'http_req_failed': ['rate<0.001'],
'biz_latency': ['p(99)<300'],
'http_reqs': ['rate>100'], // 防止脚本自身成为瓶颈
},
};
export default function () {
// 用户与商品的分布要贴近真实:80% 流量集中在 20% 的热点商品上
const sku = __ENV.HOT_SKU_POOL.split(',')[
weightedPick() // 0.8 概率取热点池
];
const res = http.post(`${__ENV.BASE_URL}/api/orders`, JSON.stringify({
sku,
qty: 1,
channel: 'app',
}), { headers: { 'Content-Type': 'application/json' } });
bizLatency.add(res.timings.duration);
check(res, { 'created': (r) => r.status === 201 });
}四点在真实压测里反复被踩的坑,都已经写进上面这段:
- 用
ramping-arrival-rate而不是ramping-vus。VU 数不是 QPS,同一个 VU 数在系统变慢时会自动降低 QPS,导致你永远测不到拐点——因为压不上去 - 每级持续至少 3 分钟。太短会测到缓存预热、连接池冷启动、JIT 未生效的中间态
- 热点分布要建模。均匀打散和真实流量的热点集中,对缓存和数据库锁的影响完全不同。均匀压测会系统性高估容量
- 给压测器自己也加阈值(
http_reqs rate)。压测机 CPU 打满时,被测系统的 QPS 曲线会下降,你会把它误认为"系统拐点"
4.3 怎么从曲线里读出容量结论
压测跑完会得到一条 QPS-延迟曲线。真正的容量点在哪,取决于你把"拐点"定义成什么。三张可直接套用的判据:
| 判据 | 定义 | 适合 | 风险 |
|---|---|---|---|
| 延迟拐点法 | p99 相对基线上涨 2 倍时的 QPS | 延迟敏感型服务 | 基线不稳定时误判 |
| 错误率法 | 错误率首次超过 SLO 允许值的 QPS | 大多数服务 | 错误往往滞后于延迟恶化,容易乐观 |
| 饱和法 | 某个资源到达硬水位(如 CPU 85%)的 QPS | 资源受限型 | 忽略了其他资源先饱和的情况 |
实践中建议三个一起看,取最小值,并且必须记录"是哪个资源先到的"。因为扩容的时候,你需要知道扩哪一层有用。
还有一件事比判据更重要:容量结论要能换算成业务单位。2000 QPS 对业务方毫无意义,支撑每秒 2000 单,即峰值客单价下每分钟 120 万元 GMV 才有意义。这个换算系数通常叫做"单量-资源换算比",它在大促期间会变(客单价变了、下单链路变了),所以也要跟着更新。
五、全链路压测:真正的瓶颈都在你的服务之外
单服务压测能测出"这台机器多能干",但真实系统的瓶颈几乎从来不在单个服务里。
上一节那个案例就是证明:订单服务能扛 2000 QPS,但数据库连接在 1200 QPS 就撑不住了。只要各服务独立压测,这种问题永远不会被发现。
5.1 全链路压测和单服务的四点差别
| 维度 | 单服务压测 | 全链路压测 |
|---|---|---|
| 流量来源 | 压测工具直接打目标服务 | 从入口打,走完整调用链 |
| 数据落点 | 可能不打数据库/打独立库 | 打真实数据库,可能污染生产数据 |
| 依赖行为 | 下游 mock | 真实下游,包括第三方 |
| 能发现的瓶颈 | 本服务的算力上限 | 连接池、缓存热点、队列分区、下游配额、跨服务锁 |
第四行是全链路压测的全部价值,也正是它最贵的地方——它要求数据隔离和流量标识,否则一次压测能在生产库里写下一万条假订单。
5.2 流量打标:全链路压测的前置工程
全链路压测有一套绕不开的设计,核心是一个贯穿全链路的标识。做法很朴素:
- 入口打标:压测流量在网关层带上一个 header,如
X-Load-Test: true - 透传:所有跨服务调用(HTTP header、消息队列的消息属性、RPC 的 attachment)原样透传这个标识
- 落地分流:数据访问层根据标识,把读写到影子库/影子表,消息发到影子 topic
- 清理:压测结束后按标识批量清理影子数据
第 2 步最容易被漏掉,一漏就全废:如果标识在某个 RPC 调用处丢了,那条链路上的写操作就直接进了真实表。所以要有检查手段——压测期间监控"真实表里出现 X-Load-Test 标记的写入行数",这个数必须为 0。
# 影子库路由示意(Java/Spring 风格,逻辑可移植)
# 关键点:路由决策只依赖一个来源(上下文标识),不做多级兜底
sharding:
shadow:
column: load_test_flag
mapping:
order_main: order_main_shadow
order_item: order_item_shadow
router:
- condition: "context.loadTestFlag == true"
target: shadow
- target: primary5.3 成本:全链路压测到底要多少人
这是决策层最关心的问题,所以给一个真实的量级参考(中型电商,核心链路 6 个服务):
| 阶段 | 一次性投入 | 每次压测的边际成本 |
|---|---|---|
| 打标与透传改造 | 3~5 人周 | — |
| 影子库/影子表搭建 | 2~3 人周 | — |
| 数据生成与清理脚本 | 1~2 人周 | 2 人日 |
| 压测执行与值守 | 1 人周 | 3~5 人日(含复盘) |
| 环境成本 | — | 影子库存储 + 压测机,按天计 |
结论通常是这样:第一次很贵(约 2 个月人力),建完之后每季度做一次的成本只有几个工作日。 所以全链路压测的决策不是"要不要做",而是"做了以后能不能坚持做",后者取决于有没有把它做成流程(见第七节)。
5.4 做不到全链路时的次优解
现实里很多团队没有资源做全链路压测。有三个次优解,按性价比排序:
- 单服务压测 + 共享资源账本:各服务各自压测,然后把所有服务的资源消耗换算到共享资源上(DB 连接、Redis 带宽、下游配额)做加总。精度不如全链路,但能发现"四个服务加起来超过了数据库上限"这类问题
- 生产流量放大(traffic replay):录一段真实流量,在预发按 2 倍/4 倍回放。弱点是没有真实的用户行为变化,但强在流量形状完全真实
- 影子流量:把生产的读请求复制一份打到新版本/新架构上,只做不写。适合验证架构改造后的容量变化,不适合验证写链路
这三条的定位要诚实:它们能替代全链路压测的 60%,剩下 40% 是跨服务的相互影响,只有在真实链路上才能看到。
六、把水位线绑到业务日历上
容量规划最容易失效的地方不是算错,是算对了但没在正确的时间动手。扩容需要提前量:采购审批、节点就绪、数据迁移、压测验证,加起来经常是 2 到 6 周。
6.1 业务日历:容量的自变量
容量不是时间的函数,是业务节奏的函数。把未来 12 周的业务事件先摆出来,再倒推技术动作:
| 业务事件 | 对容量的影响 | 需要提前多久 | 技术动作 |
|---|---|---|---|
| 大促(如 618 / 双 11) | 峰值 5~15 倍,且集中在几分钟内 | 6~8 周 | 全链路压测、扩容、限流预案、降级开关验证 |
| 月初结算 / 账单日 | 定时任务集中,DB IO 峰值 | 2~3 周 | 任务错峰、只读实例、批量任务限速 |
| 版本发布日 | 瞬时 CPU(编译、镜像拉取、缓存重建) | 1 周 | 避开高峰时段、灰度节奏降速 |
| 新增渠道 / 新客群放量 | 流量结构变化,热点可能转移到新 SKU | 3~4 周 | 重测热点分布、缓存穿透防护 |
| 营销活动(发券、秒杀) | 瞬时并发,且伴随恶意流量 | 2~3 周 | 排队/抽签、防刷、单用户限流 |
| 数据迁移 / 库表拆分 | 期间双写,双份资源消耗 | 4~6 周 | 双写容量核算、迁移窗口选择 |
| 季节性增长(如暑期) | 基线整体上移 | 4 周 | 按周增速外推、提前 4 周扩 |
6.2 大促备战:一张倒排表
大促是容量规划一年里唯一不能失败的那一次,所以值得单独一张表。以 T 为活动日:
| 时间点 | 必做 | 出口标准 |
|---|---|---|
| T-8 周 | 定目标峰值(含增长与倍率),资源预算审批 | 峰值 QPS 与业务目标双向签字 |
| T-6 周 | 全链路压测第一次,找瓶颈 | 瓶颈列表已出,按资源命名 |
| T-5 周 | 扩容动作第一批(无状态服务、缓存) | 水位回落到软水位以下 |
| T-4 周 | 有状态层扩容(DB 连接、分片、只读实例) | 共享资源余量 > 50% |
| T-3 周 | 第二次全链路压测,验证扩容效果 | 目标峰值下 p99 与错误率达标 |
| T-2 周 | 限流与降级演练(第 20 篇的 Game Day) | 降级开关全部被验证过一次 |
| T-1 周 | 冻结变更(只允许 C1 修复) | 变更分级门禁已生效(第 19 篇) |
| T-1 天 | 确认回滚点、值守排班、告警静默规则 | 值班表与升级链已公示 |
| T 当天 | 分级放量,按水位实时决策 | 水位看板在大屏上 |
| T+1 天 | 复盘:实际峰值 vs 预测峰值 | 预测误差 < 20%,超出则更新模型 |
倒数第二行值得多说一句:实际峰值与预测峰值的误差,是这个团队容量规划能力的唯一硬指标。第一年误差 50% 是正常的,第二年到 20% 以内说明模型可用。如果连续三年误差都很大,说明每次都是"凭感觉乘 2 倍",那不如老老实实说这是拍脑袋。
6.3 扩容的三种形态,选错会亏钱
| 形态 | 触发方式 | 提前量 | 成本 | 适合 |
|---|---|---|---|---|
| 预留扩容 | 人工提前加节点 | 天~周 | 高(按峰值付费) | 可预测的大促峰值 |
| 自动扩容 | HPA / CA 按指标伸缩 | 分钟级 | 中(只付实际用的) | 日常波动、缓坡增长 |
| 过载保护 | 限流、排队、降级 | 实时 | 低 | 突增、恶意流量、峰值超过容量时的兜底 |
三者的关系常被搞反:很多团队把自动扩容当成万能方案,但自动扩容对秒级突增基本无效——从指标上升、指标采集、触发伸缩、到 Pod 就绪接管流量,快的也要 1~3 分钟。大促开场那一分钟,只能靠预留 + 限流。
第 16 篇讲 HPA 的阈值为什么会被 requests 拖偏,这里补上另一半:HPA 的 max replicas 应该由容量模型算出,而不是随手写 50。 如果单副本 2000 QPS,目标峰值 30000 QPS,那么 max 至少要 15,加上滚动更新和节点故障的余量,写 20 才合理;写 50 只会让你在真正需要的时候,扩出一堆抢不到资源的 Pending Pod。
6.4 反向的问题:缩容
容量规划不只管扩,也管缩。缩容的收益是钱,风险是下一次峰值。
三条纪律:
- 缩容按"周"为粒度,不按"天"。凌晨低谷时缩到 8 个副本,早上 8 点扩回 40 个,看起来省钱,但每次扩容都有冷启动和连接池重建成本,且一旦扩不起来就是事故
- 缩容必须留出至少一次失败的余量。缩容操作本身失败(节点不可用、镜像拉取失败)不能导致服务降级
- 缩容窗口避开业务日历上的敏感期,尤其不要在大促前一周做任何"优化"
七、把容量做成一个流程,而不是一次项目
7.1 一个季度一轮的循环
容量规划的真正难点不是技术,是没人对"三个月后够不够"负责。运维负责"现在不出事",开发负责"需求交付",财务负责"别超预算",中间那块空地就是容量。
所以需要一个明确的流程,把这块空地圈给某个人(通常是 SRE 团队里的一个人,兼职也可以)。一个季度四步:
| 周次 | 动作 | 产出 |
|---|---|---|
| 第 1 周 | 读上一季度的实际峰值与预测误差 | 上一季预测准确度,模型修正项 |
| 第 2 周 | 全量水位体检,输出"可撑周数" | 每资源的余量与衰退趋势 |
| 第 3~4 周 | 单服务压测(覆盖核心链路) | 单机承载量更新 |
| 第 5~6 周 | 全链路压测(大促季度必做) | 瓶颈清单 + 业务承载量换算 |
| 第 7 周 | 出预算申请,附带数据 | 扩容预算与优先级 |
| 第 8 周 | 归档到基线库,写一页纪要 | 下季度第一周的输入 |
八周里有六周是日常工作的一部分,真正占满的只有压测那两周。这个流程能跑起来的前提,是它不依赖某个人的热情——所以第 1 周的"预测误差"和第 8 周的"归档"必须强制做,它们决定了这套东西能不能迭代。
7.2 度量:容量规划做得好不好,看这五个数
| 指标 | 定义 | 参考目标 |
|---|---|---|
| 峰值预测误差 | |实际峰值 - 预测峰值| / 预测峰值 | < 20% |
| 水位健康度 | 关键资源中,P95 在软水位以下的比例 | > 90% |
| 容量事故数 | 因容量不足导致的 P2 及以上故障数 | 0,且逐季度保持 |
| 压测结论复用率 | 引用过基线数据的决策数 / 总容量决策数 | > 60% |
| 扩容提前量 | 从"水位过软线"到"扩容生效"的实际天数 | < 2 周 |
第二个指标最容易被忽略但最有用:它衡量的是"平时有没有余量"。一个季度里如果有 20% 的关键资源长期贴着软水位,那就是在赌下一次流量不会更猛。
第五个指标是流程是否真的在跑的照妖镜。只要扩容提前量超过 2 周,说明水位体检的频率不够,或者扩容动作的审批链太长——两种情况都会让下一次事故变得"不可避免"。
7.3 谁来看这些数字
容量看板最容易的失败方式是:做了一个很漂亮的 Grafana 面板,然后只有 SRE 自己看。
三个必须给它看的人:
- 业务方 / 运营:他们需要知道"现在系统还能接多少单量",因为这直接决定营销活动的力度
- 研发负责人:他们需要知道"哪个服务是下一个瓶颈",因为这决定技术债的优先级
- 财务 / 采购:他们需要知道"这次扩容为什么要花这些钱",以及"不花会带来多少风险"
对第三类人,讲"CPU 使用率 78%"是没有用的。讲"下季度峰值预计 3.2 万单/分钟,当前容量 2.4 万,缺口 33%,对应 12 万元季度成本,和不扩对应的风险是峰值期间约 40 分钟的限流"——这才叫汇报。
容量规划在组织里的本质,是把一个技术判断翻译成一个带数字的决策。翻译做不好,再准的模型也只会变成一份没人读的文档。
八、五个真实的坑
坑一:用平均值做容量决策
这是最普遍也最贵的一个。前文案例里,"CPU 平均 40%"掩盖了"峰值 78%"。平均值有个很坏的特性:它让尖峰看起来不存在,而容量事故 100% 都发生在尖峰。
对策很简单但必须制度化:所有容量相关的看板和数据,默认展示 P95 或 P99,并把"均值"从这些面板上撤掉。 均值只在算成本的时候有用。
坑二:压测环境的下游是假的
压测时把下游全部 mock,测出来的"单机 3000 QPS"在真实链路里可能只有 800。因为真实下游有延迟、有超时、有重试——而重试是容量杀手,它会在下游变慢时把请求量放大 2~3 倍。
对策:mock 必须有真实延迟分布(P50 和 P99 都要对),并且要验证重试策略——下游返回 503 时,客户端重试几次、间隔多久、有没有退避。这条在压测里经常被漏掉,因为它不在被测服务里。
坑三:把扩容当成万能药
本文开头的案例已经说明:扩容只解决"算力不足",对连接池、锁、配额、热点 key 这类共享资源瓶颈完全无效,甚至有害。
对策:每次扩容决策前,先问一句"瓶颈资源是什么"。如果答不上来,说明还没到扩容的时候,先去做压测。
坑四:容量模型建完就不管了
业务口径变了(新增下单入口、客单价结构变了、新的渠道接入),容量模型里的换算系数就失效了。最常见的表现是:模型说"还能撑 6 周",实际 2 周就撞墙了。
对策:把换算系数的来源也写进模型。如果"单量-资源换算比"来自某次全链路压测,那压测之后每一次业务结构调整都要重新确认它。能追溯来源的模型才会被维护,拍脑袋来的系数一定会烂掉。
坑五:忽略成本的另一面
第 16 篇讲过降本,这一篇讲容量,两个方向会互相拉扯:为了降本把 requests 调小,容量模型的分母就变了;为了容量把副本数拉满,账单会失控。
对策:把两个话题放进同一张表里讨论。 左侧是"单位请求的资源消耗量",右侧是"单价",中间是"承诺的峰值容量"。任何一次调整,都同时报出对这三列的影响。这样降本团队和容量团队就不再是两拨对立的人,而是同一道算术题的两个输入。
九、容量自检清单(可直接勾选)
按季度过一遍,20 项:
基础数据
- 核心链路的每个服务,有单机承载量记录(带镜像版本、资源规格、数据规模、冷热缓存、下游配置)
- 每个关键资源(含 DB 连接、队列积压、第三方配额)都有软水位与硬水位
- 水位看板用的是 P95/P99,不是均值
- 容量结论都换算成了业务单位(单量/分钟、GMV/分钟、可支撑 DAU)
- 单量-资源换算比的来源可追溯
压测
- 压测脚本在版本库里,可一键执行
- 数据集可由脚本按生产分布重建,且已脱敏
- 压测环境规格写进配置文件,与结论一同归档
- 下游 mock 有真实延迟分布,且验证过重试策略
- 热点分布已建模(不是均匀打散)
- 压测器自身有阈值,排除"压测机打满"的误判
- 每次压测的结论都归档成结构化数据,能对比历史版本
全链路
- 压测流量有全链路标识,且能验证标识没丢
- 数据落在影子库/影子表,压测后可清理
- 每次全链路压测有明确的"发现问题清单",且按资源命名
业务日历
- 未来 12 周的业务事件已列出,并映射到容量影响
- 大促有倒排表,T-8 周启动
- 自动扩容的
max replicas由容量模型算出,不是随手写 - 限流与降级方案在峰值场景下被演练过(引用第 20 篇的 Game Day)
- 本季度实际峰值与预测峰值的误差已复盘
十、从零开始的四阶段路线
阶段一(第 1~2 周):先有分母
目标不是压测,是把"容量"这个词变成有数据的东西。
- 列出核心链路的资源清单(含共享资源和第三方配额)
- 给每个资源定软/硬水位,接进第 3.4 节那个水位脚本
- 看板上把均值撤掉,换 P95
- 产出:一张水位体检表,第一版全是"未知"也没关系
这一段的价值,是让所有人第一次看到"原来数据库连接只剩这么多"。
阶段二(第 3~6 周):单机承载量
- 给最关键的两个服务写压测脚本,进版本库
- 建立数据集生成脚本
- 测出单机承载量,写进 Wiki,带完整条件
- 产出:两份带结论的压测报告,和一套能重复跑的仓库结构
阶段三(第 2~3 月):全链路与业务日历
- 决定是否建全链路压测(第 5.3 节的成本表可以作为决策依据)
- 如果做:打标 + 影子库,先覆盖最核心的一条链路,不要一次全上
- 如果不做:用"单服务压测 + 共享资源账本"做加总,明确写下精度局限
- 同时把未来 12 周的业务日历整理出来,映射到容量影响
- 产出:一条链路的全链路压测结果,和一张倒排表
阶段四(第 4~6 月):进流程、进预算
- 把八周循环固化到季度节奏里
- 把"峰值预测误差"变成季度复盘的固定议题
- 扩容预算申请以容量数据为依据,而不是"经验估算"
- 和降本议题放在同一张表里讨论(第 8 节第五个坑)
- 产出:一套能自己运转的流程,和一个季度比一个季度准的模型
十一、和前面几篇的关系
- 第 16 篇(容器成本优化):本篇是它的镜像面。第 16 篇问"钱花在哪、能省多少",本篇问"余量还有多少、什么时候不够"。两者共用同一个基础数据——单位请求的资源消耗量,只是方向相反
- 第 19 篇(变更安全体系):第 19 篇的灰度判别指标需要分母——"错误率差值 0.2%"在容量只用了 30% 和已经用到 90% 的两个时刻,含义完全不同。容量水位是判别指标的参照系
- 第 20 篇(混沌工程):演练的注入比例(5% 流量、1 个实例)需要容量数据来定;反过来,限流和降级方案必须在峰值场景下演练过,否则"过载保护"只是配置
- 第 3、10 篇(SLO 与错误预算):容量不足会直接烧掉错误预算,而且是所有故障源里最"可预测"的那一类——这恰恰是容量规划的意义:把可预测的预算消耗,从"事故"降级成"计划"
- 第 7、12、17 篇(可观测性):水位看板的指标来源全是这三篇的成果。没有按版本、按实例、按下游切分的指标,连"瓶颈在哪一层"都判断不了
- 第 23 篇(数据库 SRE):本篇把数据库当成一个"共享资源"给了容量上限,但连接池耗尽、主从延迟、备份恢复这些数据层细节,留给第 23 篇
- W8 / W12(价值量化与成熟度模型):容量规划是 SRE 价值最容易量化的场景之一——"避免了一次峰值限流"可以直接换算成 GMV。这条线留给后面两篇展开
写在最后
如果这篇只带走一句:
容量不是一个数字,是一条"还能撑多久"的曲线。
"还能扛一倍流量"这句话,在没写条件的时候等于什么都没说。加上条件才成立:在什么数据规模、什么缓存状态、什么热点分布、什么下游行为下,还能扛一倍。
如果只带走一个动作:
挑一个你最担心的核心服务,从监控里把它过去 30 天同一时段的 P95 拉出来,和它当前的资源上限比一下,算一个"按现在增速还能撑几周"。
这个数字大概率会让人不舒服,也大概率会立刻改变下一周的工作排序。容量规划不需要从压测开始,可以从看清自己现在站得多靠边开始。
本文开头的那个案例,如果团队早就有一条连接数水位线,20:00 那一刻根本不会发生——因为在三天前,那条曲线就会告诉他们,订单库的连接数在高峰时段已经到了 82%。
本文是 SRE 实战系列第 21 篇,也是第二循环主链的最后一环。第 19 篇讲怎么不让变更引入故障,第 20 篇讲怎么主动验证韧性,这一篇讲怎么知道自己还剩多少余量——三者合起来,是"在故障发生之前"能做的全部。下一篇(第 22 篇)换个视角:当故障真的发生了,组织该怎么跑一次事故——谁说话、谁决策、谁对外。